--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit a8a9eb3ff9168e00926505dbf1f16b6c85c85c57
Parents : ef040a9
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-05-06T00:22:02-05:00
feat(Telephony): update telephony features with voicemail session management and configuration updates
Changes
13 files changed, 431 insertions(+), 44 deletions(-)
Diff
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 86e752bf..267e9bde 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -272,6 +272,8 @@ def list_host_network_interfaces():
class ReticulumMeshChat:
+ DEFAULT_AUTOCONNECT_DISCOVERED_INTERFACES = 3
+
def __init__(
self,
identity: RNS.Identity,
@@ -2929,6 +2931,12 @@ class ReticulumMeshChat:
if not ctx:
return
+ # Reject all calls if telephony is disabled
+ if not ctx.config.telephone_enabled.get():
+ if ctx.telephone_manager.telephone:
+ ctx.telephone_manager.telephone.hangup()
+ return
+
if ctx.telephone_manager and ctx.telephone_manager.initiation_status:
print(
"on_incoming_telephone_call: Ignoring as we are currently initiating an outgoing call.",
@@ -2938,40 +2946,40 @@ class ReticulumMeshChat:
caller_hash = caller_identity.hash.hex()
# Check if caller is blocked
- if self.is_destination_blocked(caller_hash):
+ if self.is_destination_blocked(caller_hash, context=ctx):
print(f"Rejecting incoming call from blocked source: {caller_hash}")
- if self.telephone_manager.telephone:
- self.telephone_manager.telephone.hangup()
+ if ctx.telephone_manager.telephone:
+ ctx.telephone_manager.telephone.hangup()
return
# Check for Do Not Disturb
- if self.config.do_not_disturb_enabled.get():
+ if ctx.config.do_not_disturb_enabled.get():
print(f"Rejecting incoming call due to Do Not Disturb: {caller_hash}")
- if self.telephone_manager.telephone:
+ if ctx.telephone_manager.telephone:
# Use a small delay to ensure LXST state is ready for hangup
threading.Timer(
0.5,
- lambda: self.telephone_manager.telephone.hangup(),
+ lambda: ctx.telephone_manager.telephone.hangup(),
).start()
return
# Check if only allowing calls from contacts, or blocking all from strangers
if (
- self.config.telephone_allow_calls_from_contacts_only.get()
- or self.config.block_all_from_strangers.get()
+ ctx.config.telephone_allow_calls_from_contacts_only.get()
+ or ctx.config.block_all_from_strangers.get()
):
- contact = self.database.contacts.get_contact_by_identity_hash(caller_hash)
+ contact = ctx.database.contacts.get_contact_by_identity_hash(caller_hash)
if not contact:
print(f"Rejecting incoming call from non-contact: {caller_hash}")
- if self.telephone_manager.telephone:
+ if ctx.telephone_manager.telephone:
threading.Timer(
0.5,
- lambda: self.telephone_manager.telephone.hangup(),
+ lambda: ctx.telephone_manager.telephone.hangup(),
).start()
return
# Trigger voicemail handling
- self.voicemail_manager.handle_incoming_call(caller_identity)
+ ctx.voicemail_manager.handle_incoming_call(caller_identity)
print(f"on_incoming_telephone_call: {caller_identity.hash.hex()}")
ch = caller_identity.hash.hex()
@@ -3012,7 +3020,7 @@ class ReticulumMeshChat:
if not ctx:
return
# Stop voicemail recording if active
- self.voicemail_manager.stop_recording()
+ ctx.voicemail_manager.stop_recording()
print(
f"on_telephone_call_ended: {caller_identity.hash.hex() if caller_identity else 'Unknown'}",
@@ -3027,8 +3035,8 @@ class ReticulumMeshChat:
remote_identity_hash = caller_identity.hash.hex()
remote_identity_name = self.get_name_for_identity_hash(remote_identity_hash)
- is_incoming = self.telephone_manager.call_is_incoming
- status_code = self.telephone_manager.call_status_at_end
+ is_incoming = ctx.telephone_manager.call_is_incoming
+ status_code = ctx.telephone_manager.call_status_at_end
status_map = {
0: "Busy",
@@ -3042,10 +3050,10 @@ class ReticulumMeshChat:
status_text = status_map.get(status_code, f"Status {status_code}")
duration = 0
- if self.telephone_manager.call_start_time:
- duration = int(time.time() - self.telephone_manager.call_start_time)
+ if ctx.telephone_manager.call_start_time:
+ duration = int(time.time() - ctx.telephone_manager.call_start_time)
- self.database.telephone.add_call_history(
+ ctx.database.telephone.add_call_history(
remote_identity_hash=remote_identity_hash,
remote_identity_name=remote_identity_name,
is_incoming=is_incoming,
@@ -3055,12 +3063,12 @@ class ReticulumMeshChat:
)
# Trigger missed call notification if it was an incoming call that ended without being established
- if is_incoming and not self.telephone_manager.call_was_established:
+ if is_incoming and not ctx.telephone_manager.call_was_established:
# Check if we should suppress the notification/websocket message
# If DND was on, we still record it but maybe skip the noisy websocket?
# Actually, persistent notification is good.
- self.database.misc.add_notification(
+ ctx.database.misc.add_notification(
notification_type="telephone_missed_call",
remote_hash=remote_identity_hash,
title="Missed Call",
@@ -3069,10 +3077,10 @@ class ReticulumMeshChat:
# Skip websocket broadcast if DND or contacts-only was likely the reason
is_filtered = False
- if self.config.do_not_disturb_enabled.get():
+ if ctx.config.do_not_disturb_enabled.get():
is_filtered = True
- elif self.config.telephone_allow_calls_from_contacts_only.get():
- contact = self.database.contacts.get_contact_by_identity_hash(
+ elif ctx.config.telephone_allow_calls_from_contacts_only.get():
+ contact = ctx.database.contacts.get_contact_by_identity_hash(
remote_identity_hash,
)
if not contact:
@@ -6510,6 +6518,7 @@ class ReticulumMeshChat:
),
"autoconnect_discovered_interfaces": reticulum_config.get(
"autoconnect_discovered_interfaces",
+ ReticulumMeshChat.DEFAULT_AUTOCONNECT_DISCOVERED_INTERFACES,
),
"default_bootstrap_only": ReticulumMeshChat._reticulum_yes_no_preference(
reticulum_config.get("default_bootstrap_only"),
@@ -6536,7 +6545,14 @@ class ReticulumMeshChat:
if key not in data:
return
value = data.get(key)
- if value is None or value == "":
+ # Treat 0 for autoconnect_discovered_interfaces the same as unset,
+ # since Reticulum interprets 0 as False, causing bootstrap_only
+ # interfaces to flap (0 >= 0 evaluates to True).
+ if (
+ value is None
+ or value == ""
+ or (key == "autoconnect_discovered_interfaces" and value == 0)
+ ):
reticulum_config.pop(key, None)
else:
if key in (
@@ -6585,6 +6601,7 @@ class ReticulumMeshChat:
),
"autoconnect_discovered_interfaces": reticulum_config.get(
"autoconnect_discovered_interfaces",
+ ReticulumMeshChat.DEFAULT_AUTOCONNECT_DISCOVERED_INTERFACES,
),
"default_bootstrap_only": ReticulumMeshChat._reticulum_yes_no_preference(
reticulum_config.get("default_bootstrap_only"),
@@ -7059,13 +7076,19 @@ class ReticulumMeshChat:
"initiation_status": self.telephone_manager.initiation_status,
"initiation_target_hash": initiation_target_hash,
"initiation_target_name": initiation_target_name,
+ # Silence web audio during voicemail
"web_audio": {
- "enabled": getattr(
- self.config.telephone_web_audio_enabled,
- "get",
- lambda: False,
- )()
- or _is_chaquopy_android(),
+ "enabled": (
+ getattr(
+ self.config.telephone_web_audio_enabled,
+ "get",
+ lambda: False,
+ )()
+ or _is_chaquopy_android()
+ )
+ and not bool(
+ getattr(self.voicemail_manager, "is_recording", False),
+ ),
"allow_fallback": getattr(
self.config.telephone_web_audio_allow_fallback,
"get",
@@ -10825,6 +10848,9 @@ class ReticulumMeshChat:
status=400,
)
self.database.messages.mark_conversations_as_read(destination_hashes)
+ # Keep notification viewed state in sync so the bell never
+ # disagrees with the conversation list.
+ self.database.messages.mark_all_notifications_as_viewed(destination_hashes)
return web.json_response({"message": "Conversations marked as read"})
@routes.post("/api/v1/lxmf/conversations/bulk-delete")
@@ -10901,6 +10927,9 @@ class ReticulumMeshChat:
# mark lxmf conversation as read
self.database.messages.mark_conversation_as_read(destination_hash)
+ # Keep notification viewed state in sync so the bell never
+ # disagrees with the conversation list.
+ self.database.messages.mark_notification_as_viewed(destination_hash)
return web.json_response(
{
@@ -10920,10 +10949,14 @@ class ReticulumMeshChat:
self.database.messages.mark_all_notifications_as_viewed(
destination_hashes,
)
+ # Keep conversation read state in sync
+ self.database.messages.mark_conversations_as_read(destination_hashes)
else:
# mark all LXMF conversations as viewed if no hashes provided
# (this happens when "Clear All" is clicked)
self.database.messages.mark_all_notifications_as_viewed()
+ # Also mark all conversations as read
+ self.database.messages.mark_all_conversations_as_read()
if notification_ids:
# mark system notifications as viewed
@@ -10955,6 +10988,7 @@ class ReticulumMeshChat:
# 2. Fetch unread LXMF conversations if requested
conversations = []
user_facing_peer_hashes = set()
+ total_unread_peer_hashes = set()
if filter_unread:
local_hash = self.local_lxmf_destination.hexhash
db_conversations = self.message_handler.get_conversations(
@@ -10972,10 +11006,17 @@ class ReticulumMeshChat:
else:
other_user_hash = db_message["source_hash"]
- # If the latest incoming message is not user-facing
- # (reaction, telemetry, icon update, empty payload)
- # fall back to the most recent user-facing incoming
- # message so silent activity never raises the bell.
+ if not self._lxmf_sieve_suppresses_notifications(
+ other_user_hash,
+ message_title=db_message.get("title"),
+ message_content=db_message.get("content"),
+ ):
+ if not self.database.messages.is_notification_viewed(
+ other_user_hash,
+ db_message["timestamp"],
+ ):
+ total_unread_peer_hashes.add(other_user_hash)
+
latest_for_preview = db_message
if not is_user_facing_lxmf_payload(
db_message.get("fields"),
@@ -11125,10 +11166,12 @@ class ReticulumMeshChat:
# dropdown contents (no false bell triggers from reactions,
# telemetry, icon updates, or empty payloads).
lxmf_unread_count = 0
+ lxmf_total_unread_count = 0
local_hash = self.local_lxmf_destination.hexhash
if filter_unread:
# Already computed during the listing pass.
lxmf_unread_count = len(user_facing_peer_hashes)
+ lxmf_total_unread_count = len(total_unread_peer_hashes)
else:
unread_conversations = self.message_handler.get_conversations(
local_hash,
@@ -11143,6 +11186,18 @@ class ReticulumMeshChat:
else:
other_user_hash = conv["source_hash"]
+ # Total unread count (regardless of user-facing)
+ if not self._lxmf_sieve_suppresses_notifications(
+ other_user_hash,
+ message_title=conv.get("title"),
+ message_content=conv.get("content"),
+ ):
+ if not self.database.messages.is_notification_viewed(
+ other_user_hash,
+ conv["timestamp"],
+ ):
+ lxmf_total_unread_count += 1
+
latest_for_check = conv
if not is_user_facing_lxmf_payload(
conv.get("fields"),
@@ -11192,6 +11247,7 @@ class ReticulumMeshChat:
{
"notifications": all_notifications[:limit],
"unread_count": total_unread_count,
+ "lxmf_total_unread_count": lxmf_total_unread_count,
},
)
except Exception as e:
@@ -13354,6 +13410,16 @@ class ReticulumMeshChat:
self._parse_bool(data["do_not_disturb_enabled"]),
)
+ if "telephone_enabled" in data:
+ value = self._parse_bool(data["telephone_enabled"])
+ self.config.telephone_enabled.set(value)
+ if (
+ not value
+ and self.telephone_manager
+ and self.telephone_manager.telephone
+ ):
+ self.telephone_manager.telephone.hangup()
+
if "telephone_allow_calls_from_contacts_only" in data:
self.config.telephone_allow_calls_from_contacts_only.set(
self._parse_bool(data["telephone_allow_calls_from_contacts_only"]),
@@ -14543,6 +14609,7 @@ class ReticulumMeshChat:
"map_tile_server_url": ctx.config.map_tile_server_url.get(),
"map_nominatim_api_url": ctx.config.map_nominatim_api_url.get(),
"do_not_disturb_enabled": ctx.config.do_not_disturb_enabled.get(),
+ "telephone_enabled": ctx.config.telephone_enabled.get(),
"telephone_allow_calls_from_contacts_only": ctx.config.telephone_allow_calls_from_contacts_only.get(),
"telephone_announce_enabled": ctx.config.telephone_announce_enabled.get(),
"telephone_audio_profile_id": ctx.config.telephone_audio_profile_id.get(),
diff --git a/meshchatx/src/backend/config_manager.py b/meshchatx/src/backend/config_manager.py
index 23b1a772..8ddf4cb9 100644
--- a/meshchatx/src/backend/config_manager.py
+++ b/meshchatx/src/backend/config_manager.py
@@ -188,6 +188,11 @@ class ConfigManager:
self.ringtone_volume = self.IntConfig(self, "ringtone_volume", 100)
# telephony config
+ self.telephone_enabled = self.BoolConfig(
+ self,
+ "telephone_enabled",
+ True,
+ )
self.do_not_disturb_enabled = self.BoolConfig(
self,
"do_not_disturb_enabled",
@@ -196,7 +201,7 @@ class ConfigManager:
self.telephone_allow_calls_from_contacts_only = self.BoolConfig(
self,
"telephone_allow_calls_from_contacts_only",
- False,
+ True,
)
self.telephone_announce_enabled = self.BoolConfig(
self,
diff --git a/meshchatx/src/backend/database/messages.py b/meshchatx/src/backend/database/messages.py
index ad0cb181..9b04b43e 100644
--- a/meshchatx/src/backend/database/messages.py
+++ b/meshchatx/src/backend/database/messages.py
@@ -338,15 +338,30 @@ class MessageDAO:
with self.provider:
self.provider.executemany(
"""
- INSERT INTO lxmf_conversation_read_state (destination_hash, last_read_at, created_at, updated_at)
+ INSERT INTO lxmf_conversation_read_state (destination_hash, last_read_at, created_at, updated_at)
VALUES (?, ?, ?, ?)
- ON CONFLICT(destination_hash) DO UPDATE SET
+ ON CONFLICT(destination_hash) DO UPDATE SET
last_read_at = EXCLUDED.last_read_at,
updated_at = EXCLUDED.updated_at
""",
[(h, now, now, now) for h in destination_hashes],
)
+ def mark_all_conversations_as_read(self):
+ now = datetime.now(UTC).isoformat()
+ self.provider.execute(
+ """
+ INSERT INTO lxmf_conversation_read_state (destination_hash, last_read_at, created_at, updated_at)
+ SELECT peer_hash, ?, ?, ? FROM lxmf_messages
+ WHERE peer_hash IS NOT NULL
+ GROUP BY peer_hash
+ ON CONFLICT(destination_hash) DO UPDATE SET
+ last_read_at = EXCLUDED.last_read_at,
+ updated_at = EXCLUDED.updated_at
+ """,
+ (now, now, now),
+ )
+
def is_conversation_unread(self, destination_hash):
row = self.provider.fetchone(
"""
diff --git a/meshchatx/src/backend/telephone_manager.py b/meshchatx/src/backend/telephone_manager.py
index eebd301c..dd884042 100644
--- a/meshchatx/src/backend/telephone_manager.py
+++ b/meshchatx/src/backend/telephone_manager.py
@@ -88,6 +88,7 @@ class TelephoneManager:
self._path_poll_interval_s = 0.05
self._path_retry_interval_s = 1.5
self._status_poll_interval_s = 0.1
+ self.is_voicemail_session_active = False
@property
def is_recording(self):
@@ -96,6 +97,8 @@ class TelephoneManager:
def init_telephone(self):
if self.telephone is not None:
return
+ if self.config_manager and not self.config_manager.telephone_enabled.get():
+ return
self.telephone = Telephone(self.identity)
# Disable busy tone played on caller side when remote side rejects, or doesn't answer
diff --git a/meshchatx/src/backend/voicemail_manager.py b/meshchatx/src/backend/voicemail_manager.py
index c8fb490a..e59cd571 100644
--- a/meshchatx/src/backend/voicemail_manager.py
+++ b/meshchatx/src/backend/voicemail_manager.py
@@ -246,7 +246,10 @@ class VoicemailManager:
)
return
- # Stop microphone if it's active to prevent local noise being sent or recorded
+ # Mark voicemail session active
+ self.telephone_manager.is_voicemail_session_active = True
+
+ # Stop microphone if it's active
if telephone.audio_input:
with contextlib.suppress(Exception):
telephone.audio_input.stop()
@@ -270,7 +273,7 @@ class VoicemailManager:
def session_job():
prev_receive_muted = self.telephone_manager.receive_muted
with contextlib.suppress(Exception):
- # Prevent remote audio from playing locally while recording voicemail
+ # Prevent remote audio from playing locally
self.telephone_manager.mute_receive()
try:
@@ -464,6 +467,8 @@ class VoicemailManager:
except Exception as e:
RNS.log(f"Error stopping recording: {e}", RNS.LOG_ERROR)
self.is_recording = False
+ finally:
+ self.telephone_manager.is_voicemail_session_active = False
def _fix_recording(self, filepath):
"""Ensure ``filepath`` is a valid OGG/Opus file.
diff --git a/meshchatx/src/backend/web_audio_bridge.py b/meshchatx/src/backend/web_audio_bridge.py
index 2992149a..2425c676 100644
--- a/meshchatx/src/backend/web_audio_bridge.py
+++ b/meshchatx/src/backend/web_audio_bridge.py
@@ -134,6 +134,9 @@ class WebAudioBridge:
tele = self._tele()
if not tele or not tele.active_call:
return False
+ # Do not attach during voicemail
+ if getattr(self.telephone_manager, "is_voicemail_session_active", False):
+ return False
self.clients.add(client)
self._ensure_remote_tx(tele)
self._ensure_rx_tee(tele)
@@ -162,6 +165,9 @@ class WebAudioBridge:
with self.lock:
if not self.tx_source:
return
+ # Drop frames during voicemail
+ if getattr(self.telephone_manager, "is_voicemail_session_active", False):
+ return
self.tx_source.push_pcm(pcm_bytes)
async def _send_bytes_to_all(self, pcm_bytes: bytes):
@@ -178,6 +184,9 @@ class WebAudioBridge:
# Rebuild transmit path with websocket-backed source
if self.tx_source:
return
+ # Do not create transmit source during voicemail
+ if getattr(self.telephone_manager, "is_voicemail_session_active", False):
+ return
try:
if hasattr(tele, "audio_input") and tele.audio_input:
tele.audio_input.stop()
diff --git a/tests/backend/test_config_manager.py b/tests/backend/test_config_manager.py
index 806ead61..71da1155 100644
--- a/tests/backend/test_config_manager.py
+++ b/tests/backend/test_config_manager.py
@@ -79,16 +79,21 @@ def test_telephony_config(db):
config.do_not_disturb_enabled.set(True)
assert config.do_not_disturb_enabled.get() is True
- # Test Contacts Only
- assert config.telephone_allow_calls_from_contacts_only.get() is False
- config.telephone_allow_calls_from_contacts_only.set(True)
+ # Test Contacts Only (defaults to True for security)
assert config.telephone_allow_calls_from_contacts_only.get() is True
+ config.telephone_allow_calls_from_contacts_only.set(False)
+ assert config.telephone_allow_calls_from_contacts_only.get() is False
# Test Call Recording
assert config.call_recording_enabled.get() is False
config.call_recording_enabled.set(True)
assert config.call_recording_enabled.get() is True
+ # Test Telephone Enabled (defaults to True)
+ assert config.telephone_enabled.get() is True
+ config.telephone_enabled.set(False)
+ assert config.telephone_enabled.get() is False
+
def test_auto_propagation_config(db):
config = ConfigManager(db)
diff --git a/tests/backend/test_incoming_call_policy.py b/tests/backend/test_incoming_call_policy.py
index 08b9d2f9..9b1b5101 100644
--- a/tests/backend/test_incoming_call_policy.py
+++ b/tests/backend/test_incoming_call_policy.py
@@ -195,3 +195,37 @@ def test_ignores_incoming_while_outgoing_initiation(policy_app):
policy_app.current_context.voicemail_manager.handle_incoming_call.assert_not_called()
async_utils.run_async.assert_not_called()
+
+
+def test_uses_passed_context_not_app_current_context(policy_app):
+ """Ensure on_incoming_telephone_call uses the ctx parameter, not self.current_context."""
+ policy_app.is_destination_blocked.return_value = False
+ policy_app.config.do_not_disturb_enabled.get.return_value = False
+ policy_app.config.telephone_allow_calls_from_contacts_only.get.return_value = True
+ policy_app.config.block_all_from_strangers.get.return_value = False
+
+ # current_context has no contact
+ policy_app.current_context.database.contacts.get_contact_by_identity_hash.return_value = None
+
+ # But a different ctx passed to the method DOES have the contact
+ other_ctx = MagicMock()
+ other_ctx.telephone_manager = policy_app.current_context.telephone_manager
+ other_ctx.config = policy_app.config
+ other_ctx.database = MagicMock()
+ other_ctx.database.contacts.get_contact_by_identity_hash.return_value = {
+ "id": 1,
+ "name": "Friend",
+ }
+ other_ctx.voicemail_manager = MagicMock()
+
+ caller = _caller_identity()
+
+ with patch("meshchatx.meshchat.AsyncUtils") as async_utils:
+ async_utils.run_async = MagicMock()
+ _run_incoming(policy_app, caller, ctx=other_ctx)
+
+ other_ctx.database.contacts.get_contact_by_identity_hash.assert_called_once_with(
+ CALLER_HASH_HEX,
+ )
+ policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_not_called()
+ other_ctx.voicemail_manager.handle_incoming_call.assert_called_once_with(caller)
diff --git a/tests/backend/test_interface_discovery.py b/tests/backend/test_interface_discovery.py
index 9e446941..70f481c9 100644
--- a/tests/backend/test_interface_discovery.py
+++ b/tests/backend/test_interface_discovery.py
@@ -150,6 +150,54 @@ async def test_reticulum_discovery_get_and_patch(temp_dir):
assert config.write_called
+@pytest.mark.asyncio
+async def test_discovery_patch_rejects_zero_autoconnect_as_unset(temp_dir):
+ config = ConfigDict(
+ {
+ "reticulum": {
+ "autoconnect_discovered_interfaces": 2,
+ },
+ "interfaces": {},
+ },
+ )
+
+ with (
+ patch("meshchatx.meshchat.generate_ssl_certificate"),
+ patch("RNS.Reticulum") as mock_rns,
+ patch("RNS.Transport"),
+ patch("LXMF.LXMRouter"),
+ ):
+ mock_reticulum = mock_rns.return_value
+ mock_reticulum.config = config
+ mock_reticulum.configpath = "/tmp/mock_config"
+ mock_reticulum.is_connected_to_shared_instance = False
+ mock_reticulum.transport_enabled.return_value = True
+
+ app = ReticulumMeshChat(
+ identity=build_identity(),
+ storage_dir=str(temp_dir),
+ reticulum_config_dir=str(temp_dir),
+ )
+
+ patch_handler = await find_route_handler(
+ app,
+ "/api/v1/reticulum/discovery",
+ "PATCH",
+ )
+ assert patch_handler
+
+ class PatchRequest:
+ @staticmethod
+ async def json():
+ return {"autoconnect_discovered_interfaces": 0}
+
+ patch_response = await patch_handler(PatchRequest())
+ patch_data = json.loads(patch_response.body)
+ # Backend treats 0 as unset and falls back to the default
+ assert patch_data["discovery"]["autoconnect_discovered_interfaces"] == 3
+ assert "autoconnect_discovered_interfaces" not in config["reticulum"]
+
+
@pytest.mark.asyncio
async def test_discovered_interfaces_respect_whitelist_and_blacklist(temp_dir):
config = ConfigDict(
diff --git a/tests/backend/test_telephone_manager_boost.py b/tests/backend/test_telephone_manager_boost.py
index 77732eb7..5f5a3a96 100644
--- a/tests/backend/test_telephone_manager_boost.py
+++ b/tests/backend/test_telephone_manager_boost.py
@@ -74,3 +74,35 @@ def test_set_callbacks(tel_manager):
assert tel_manager.on_ringing_callback == cb1
assert tel_manager.on_established_callback == cb2
assert tel_manager.on_ended_callback == cb3
+
+
+def test_is_voicemail_session_active_defaults_false(tel_manager):
+ assert tel_manager.is_voicemail_session_active is False
+
+
+@patch("meshchatx.src.backend.telephone_manager.Telephone")
+def test_init_telephone_skips_when_disabled(mock_tel_class, mock_identity, tmp_path):
+ storage_dir = tmp_path / "tel"
+ storage_dir.mkdir()
+ cfg = MagicMock()
+ cfg.telephone_enabled.get.return_value = False
+ tm = TelephoneManager(
+ mock_identity, config_manager=cfg, storage_dir=str(storage_dir)
+ )
+ tm.init_telephone()
+ assert tm.telephone is None
+ mock_tel_class.assert_not_called()
+
+
+@patch("meshchatx.src.backend.telephone_manager.Telephone")
+def test_init_telephone_creates_when_enabled(mock_tel_class, mock_identity, tmp_path):
+ storage_dir = tmp_path / "tel"
+ storage_dir.mkdir()
+ cfg = MagicMock()
+ cfg.telephone_enabled.get.return_value = True
+ tm = TelephoneManager(
+ mock_identity, config_manager=cfg, storage_dir=str(storage_dir)
+ )
+ tm.init_telephone()
+ assert tm.telephone is not None
+ mock_tel_class.assert_called_once()
diff --git a/tests/backend/test_voicemail_manager_extended.py b/tests/backend/test_voicemail_manager_extended.py
index 7afd79b2..4e83f17d 100644
--- a/tests/backend/test_voicemail_manager_extended.py
+++ b/tests/backend/test_voicemail_manager_extended.py
@@ -178,3 +178,53 @@ def test_start_voicemail_session(mock_deps, temp_dir):
# Run the job
job_func()
mock_start_rec.assert_called()
+
+
+def test_voicemail_session_sets_active_flag(mock_deps, temp_dir):
+ mock_db = MagicMock()
+ mock_config = MagicMock()
+ mock_tel_manager = MagicMock()
+ mock_tel = MagicMock()
+ mock_tel_manager.telephone = mock_tel
+ mock_tel_manager.is_voicemail_session_active = False
+
+ vm = VoicemailManager(mock_db, mock_config, mock_tel_manager, temp_dir)
+
+ mock_caller = MagicMock()
+ mock_caller.hash = b"caller"
+ mock_tel.call_status = 4
+ mock_tel.answer.return_value = True
+ mock_tel.audio_input = MagicMock()
+
+ with patch("threading.Thread"), patch("time.sleep"):
+ vm.start_voicemail_session(mock_caller)
+ assert mock_tel_manager.is_voicemail_session_active is True
+
+
+def test_stop_recording_clears_active_flag(mock_deps, temp_dir):
+ mock_db = MagicMock()
+ mock_config = MagicMock()
+ mock_tel_manager = MagicMock()
+ mock_tel = MagicMock()
+ mock_tel_manager.telephone = mock_tel
+ vm = VoicemailManager(mock_db, mock_config, mock_tel_manager, temp_dir)
+
+ vm.is_recording = True
+ vm.recording_pipeline = MagicMock()
+ vm.recording_sink = MagicMock()
+ vm.recording_filename = "test.opus"
+ vm.recording_start_time = 100
+ mock_remote_id = MagicMock()
+ mock_remote_id.hash.hex.return_value = "72656d6f7465"
+ vm.recording_remote_identity = mock_remote_id
+ vm.get_name_for_identity_hash = MagicMock(return_value="Test User")
+
+ recording_path = os.path.join(vm.recordings_dir, "test.opus")
+ os.makedirs(vm.recordings_dir, exist_ok=True)
+ with open(recording_path, "wb") as f:
+ f.write(b"fake opus data")
+
+ with patch("time.time", return_value=110), patch.object(vm, "_fix_recording"):
+ vm.stop_recording()
+
+ assert mock_tel_manager.is_voicemail_session_active is False
diff --git a/tests/backend/test_voicemail_webaudio_security.py b/tests/backend/test_voicemail_webaudio_security.py
new file mode 100644
index 00000000..756c92ad
--- /dev/null
+++ b/tests/backend/test_voicemail_webaudio_security.py
@@ -0,0 +1,77 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Fuzzing and security tests for voicemail isolation and web audio guards."""
+
+from unittest.mock import MagicMock
+
+import pytest
+from hypothesis import given, settings
+from hypothesis import strategies as st
+
+from meshchatx.src.backend.web_audio_bridge import WebAudioBridge
+
+
+@settings(deadline=None)
+@given(pcm=st.binary(min_size=0, max_size=4096))
+def test_web_audio_bridge_push_client_frame_never_crashes(pcm):
+ """Random PCM bytes must not crash push_client_frame, even during voicemail."""
+ tele_mgr = MagicMock()
+ tele_mgr.is_voicemail_session_active = True
+ bridge = WebAudioBridge(tele_mgr, MagicMock())
+ bridge.tx_source = MagicMock()
+ try:
+ bridge.push_client_frame(pcm)
+ except Exception:
+ pytest.fail("push_client_frame raised an exception")
+
+
+@settings(deadline=None)
+@given(pcm=st.binary(min_size=0, max_size=4096))
+def test_web_audio_bridge_push_client_frame_forwards_when_not_voicemail(pcm):
+ """PCM bytes must be forwarded when not in a voicemail session."""
+ tele_mgr = MagicMock()
+ tele_mgr.is_voicemail_session_active = False
+ bridge = WebAudioBridge(tele_mgr, MagicMock())
+ mock_tx = MagicMock()
+ bridge.tx_source = mock_tx
+ try:
+ bridge.push_client_frame(pcm)
+ except Exception:
+ pytest.fail("push_client_frame raised an exception")
+ if pcm:
+ mock_tx.push_pcm.assert_called_once_with(pcm)
+
+
+def test_web_audio_bridge_attach_client_refuses_without_active_call():
+ tele = MagicMock()
+ tele.active_call = None
+ tele_mgr = MagicMock()
+ tele_mgr.telephone = tele
+ tele_mgr.is_voicemail_session_active = False
+ bridge = WebAudioBridge(tele_mgr, MagicMock())
+ assert bridge.attach_client(MagicMock()) is False
+
+
+def test_web_audio_bridge_attach_client_refuses_during_voicemail():
+ tele = MagicMock()
+ tele.active_call = object()
+ tele_mgr = MagicMock()
+ tele_mgr.telephone = tele
+ tele_mgr.is_voicemail_session_active = True
+ bridge = WebAudioBridge(tele_mgr, MagicMock())
+ assert bridge.attach_client(MagicMock()) is False
+
+
+def test_web_audio_bridge_ensure_remote_tx_skips_without_telephone():
+ bridge = WebAudioBridge(MagicMock(), MagicMock())
+ bridge._ensure_remote_tx(MagicMock())
+ assert bridge.tx_source is None
+
+
+def test_web_audio_bridge_ensure_remote_tx_skips_during_voicemail():
+ tele = MagicMock()
+ tele_mgr = MagicMock()
+ tele_mgr.is_voicemail_session_active = True
+ bridge = WebAudioBridge(tele_mgr, MagicMock())
+ bridge._ensure_remote_tx(tele)
+ assert bridge.tx_source is None
diff --git a/tests/backend/test_web_audio_bridge.py b/tests/backend/test_web_audio_bridge.py
index 340488ff..e77b483e 100644
--- a/tests/backend/test_web_audio_bridge.py
+++ b/tests/backend/test_web_audio_bridge.py
@@ -205,13 +205,45 @@ def test_push_client_frame_no_op_without_tx_source():
def test_push_client_frame_forwards_to_tx_source():
- bridge = WebAudioBridge(MagicMock(), MagicMock())
+ tele_mgr = MagicMock()
+ tele_mgr.is_voicemail_session_active = False
+ bridge = WebAudioBridge(tele_mgr, MagicMock())
mock_tx = MagicMock()
bridge.tx_source = mock_tx
bridge.push_client_frame(b"\x01\x02")
mock_tx.push_pcm.assert_called_once_with(b"\x01\x02")
+def test_push_client_frame_drops_during_voicemail():
+ tele_mgr = MagicMock()
+ tele_mgr.is_voicemail_session_active = True
+ bridge = WebAudioBridge(tele_mgr, MagicMock())
+ mock_tx = MagicMock()
+ bridge.tx_source = mock_tx
+ bridge.push_client_frame(b"\x01\x02")
+ mock_tx.push_pcm.assert_not_called()
+
+
+def test_attach_client_returns_false_during_voicemail():
+ tele = MagicMock()
+ tele.active_call = object()
+ tele_mgr = MagicMock()
+ tele_mgr.telephone = tele
+ tele_mgr.is_voicemail_session_active = True
+ bridge = WebAudioBridge(tele_mgr, MagicMock())
+ assert bridge.attach_client(MagicMock()) is False
+
+
+def test_ensure_remote_tx_skips_during_voicemail():
+ tele = MagicMock()
+ tele_mgr = MagicMock()
+ tele_mgr.telephone = tele
+ tele_mgr.is_voicemail_session_active = True
+ bridge = WebAudioBridge(tele_mgr, MagicMock())
+ bridge._ensure_remote_tx(tele)
+ assert bridge.tx_source is None
+
+
@pytest.mark.asyncio
async def test_send_status_uses_tele_frame_ms():
tele = MagicMock()
@@ -263,6 +295,7 @@ def test_attach_client_success_wires_telephony_and_dedupes_client(mock_pipeline_
tele.receive_pipeline = None
tele_mgr = MagicMock()
tele_mgr.telephone = tele
+ tele_mgr.is_voicemail_session_active = False
bridge = WebAudioBridge(tele_mgr, MagicMock())
client = MagicMock()
@@ -300,6 +333,7 @@ def test_attach_rx_tee_includes_base_sink_when_audio_output_exists(mock_pipeline
tele.receive_pipeline = None
tele_mgr = MagicMock()
tele_mgr.telephone = tele
+ tele_mgr.is_voicemail_session_active = False
bridge = WebAudioBridge(tele_mgr, MagicMock())
assert bridge.attach_client(MagicMock()) is True
assert len(bridge.rx_tee.sinks) == 2
@@ -321,6 +355,7 @@ def test_attach_rx_tee_single_sink_when_no_base_audio_output(mock_pipeline_cls):
tele.receive_pipeline = None
tele_mgr = MagicMock()
tele_mgr.telephone = tele
+ tele_mgr.is_voicemail_session_active = False
bridge = WebAudioBridge(tele_mgr, MagicMock())
assert bridge.attach_client(MagicMock()) is True
assert len(bridge.rx_tee.sinks) == 1
@@ -405,7 +440,9 @@ def test_ensure_remote_tx_swallows_source_init_failure(mock_log):
tele = MagicMock()
tele.transmit_mixer = MagicMock()
tele.transmit_mixer.should_run = True
- bridge = WebAudioBridge(MagicMock(), MagicMock())
+ tele_mgr = MagicMock()
+ tele_mgr.is_voicemail_session_active = False
+ bridge = WebAudioBridge(tele_mgr, MagicMock())
bridge.tx_source = None
with patch(
"meshchatx.src.backend.web_audio_bridge.WebAudioSource",
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────